Skip to content

fix(ScrollArea): reset scroll only on direct viewport child swaps (#1349)#2045

Open
kotAPI wants to merge 2 commits into
mainfrom
fix/issue-1349-scrollarea-observer-scope
Open

fix(ScrollArea): reset scroll only on direct viewport child swaps (#1349)#2045
kotAPI wants to merge 2 commits into
mainfrom
fix/issue-1349-scrollarea-observer-scope

Conversation

@kotAPI

@kotAPI kotAPI commented Jun 24, 2026

Copy link
Copy Markdown
Collaborator

Scopes MutationObserver to viewport direct children (subtree: false) and resets scroll on panel swaps.\n\nCloses #1349

Summary by CodeRabbit

  • Bug Fixes
    • Scroll areas now reset correctly when their main content is replaced, keeping scroll position and scrollbar behavior in sync.
    • Nested content updates no longer unexpectedly jump or reset the scroll position.

@changeset-bot

changeset-bot Bot commented Jun 24, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: a68492b

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@coderabbitai

coderabbitai Bot commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

ScrollAreaRoot gains a syncResizeObservers function that disconnects and re-observes the viewport and its current direct children. The MutationObserver is narrowed from full-subtree to direct childList observation; direct child swaps now reset scrollTop/scrollLeft to 0, resync observers, recompute thumb sizes, and trigger handleScroll. A new async test confirms the reset fires only on direct child swaps, not on nested DOM mutations.

Changes

ScrollArea viewport child swap detection

Layer / File(s) Summary
MutationObserver refactor & ResizeObserver resync
src/components/ui/ScrollArea/fragments/ScrollAreaRoot.tsx
Adds syncResizeObservers to disconnect/re-observe the viewport and its direct children. Narrows MutationObserver from full-subtree to direct childList-only. On direct child swap: resets scrollTop/scrollLeft to 0, calls syncResizeObservers, recomputes thumb sizes, and calls handleScroll. On other mutations: recomputes thumb sizes only.
Async scroll-reset test
src/components/ui/ScrollArea/tests/ScrollArea.test.tsx
Adds waitFor to RTL imports and a new async test that verifies scrollTop resets to 0 only when the viewport's direct child is swapped (not on nested DOM mutations).

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

  • rad-ui/ui#1108: Introduces handleScroll, thumb refs, and viewport context that ScrollAreaRoot now calls on direct child swaps in this PR.
  • rad-ui/ui#1124: Previously modified ResizeObserver/MutationObserver behavior in ScrollAreaRoot.tsx for content sizing and thumb recalculation, which this PR further refines.

Poem

🐇 Hop hop, the scroll jumped down so far,
Then the page changed — but the thumb stayed at par!
Now direct child swaps reset the top to zero,
ResizeObservers resync — this bunny's a hero.
Nested mutations? No reset, no fuss,
The viewport stays true, you can trust us! 🌿

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: resetting ScrollArea scroll only on direct viewport child swaps.
Linked Issues check ✅ Passed The change addresses #1349 by resyncing measurement observers and resetting scroll when the displayed panel or tab content swaps.
Out of Scope Changes check ✅ Passed The changes stay within ScrollArea behavior and tests, with no unrelated feature work apparent.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/issue-1349-scrollarea-observer-scope

Comment @coderabbitai help to get the list of available commands.

@github-actions

Copy link
Copy Markdown
Contributor

Coverage

This report compares the PR with the base branch. "Δ" shows how the PR affects each metric.

Metric PR Δ
Statements 78.25% +2.52%
Branches 60.93% +1.76%
Functions 63.68% +1.93%
Lines 79.84% +2.58%

Coverage improved or stayed the same. Great job!

Run npm run coverage:ci locally for detailed reports and target untested areas to raise these numbers.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/components/ui/ScrollArea/fragments/ScrollAreaRoot.tsx`:
- Around line 69-74: The direct reset predicate in ScrollAreaRoot is too broad
because it treats any childList change on the viewport as a swap. Update the
mutation handling around directChildSwap to inspect the full mutations batch and
only trigger a reset when the viewport has both addedNodes and removedNodes,
indicating an actual child replacement. Keep the logic localized to the viewport
mutation check in ScrollAreaRoot.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 963057b3-7f69-468d-accb-8f4228036bdd

📥 Commits

Reviewing files that changed from the base of the PR and between fe87b36 and a68492b.

📒 Files selected for processing (2)
  • src/components/ui/ScrollArea/fragments/ScrollAreaRoot.tsx
  • src/components/ui/ScrollArea/tests/ScrollArea.test.tsx

Comment on lines +69 to +74
const directChildSwap = mutations.some(
(mutation) =>
mutation.type === 'childList'
&& mutation.target === viewport
&& (mutation.addedNodes.length > 0 || mutation.removedNodes.length > 0)
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Narrow the reset predicate to actual child replacements.

directChildSwap currently fires for any direct child add/remove, so appending or removing an item directly under the viewport will reset the user to the top. Track added and removed nodes across the batch and reset only when both occur for the viewport.

🐛 Proposed predicate tightening
-            const directChildSwap = mutations.some(
-                (mutation) =>
-                    mutation.type === 'childList'
-                    && mutation.target === viewport
-                    && (mutation.addedNodes.length > 0 || mutation.removedNodes.length > 0)
-            );
+            const viewportChildMutations = mutations.filter(
+                mutation => mutation.type === 'childList' && mutation.target === viewport
+            );
+            const directChildSwap =
+                viewportChildMutations.some(mutation => mutation.addedNodes.length > 0)
+                && viewportChildMutations.some(mutation => mutation.removedNodes.length > 0);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const directChildSwap = mutations.some(
(mutation) =>
mutation.type === 'childList'
&& mutation.target === viewport
&& (mutation.addedNodes.length > 0 || mutation.removedNodes.length > 0)
);
const viewportChildMutations = mutations.filter(
mutation => mutation.type === 'childList' && mutation.target === viewport
);
const directChildSwap =
viewportChildMutations.some(mutation => mutation.addedNodes.length > 0)
&& viewportChildMutations.some(mutation => mutation.removedNodes.length > 0);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/ui/ScrollArea/fragments/ScrollAreaRoot.tsx` around lines 69 -
74, The direct reset predicate in ScrollAreaRoot is too broad because it treats
any childList change on the viewport as a swap. Update the mutation handling
around directChildSwap to inspect the full mutations batch and only trigger a
reset when the viewport has both addedNodes and removedNodes, indicating an
actual child replacement. Keep the logic localized to the viewport mutation
check in ScrollAreaRoot.

Comment on lines +192 to +198
act(() => {
const nested = document.createElement('span');
nested.textContent = 'nested';
screen.getByTestId('panel-a').appendChild(nested);
});

expect(viewport.scrollTop).toBe(120);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Flush the nested mutation before the negative assertion.

MutationObserver callbacks are async, so Line 198 can pass before a broken nested-mutation reset runs. Await a microtask after appending the nested node, then assert the scroll position.

💚 Proposed test hardening
         act(() => {
             const nested = document.createElement('span');
             nested.textContent = 'nested';
             screen.getByTestId('panel-a').appendChild(nested);
         });
 
+        await act(async() => {
+            await Promise.resolve();
+        });
+
         expect(viewport.scrollTop).toBe(120);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
act(() => {
const nested = document.createElement('span');
nested.textContent = 'nested';
screen.getByTestId('panel-a').appendChild(nested);
});
expect(viewport.scrollTop).toBe(120);
act(() => {
const nested = document.createElement('span');
nested.textContent = 'nested';
screen.getByTestId('panel-a').appendChild(nested);
});
await act(async() => {
await Promise.resolve();
});
expect(viewport.scrollTop).toBe(120);

@kotAPI

kotAPI commented Jun 24, 2026

Copy link
Copy Markdown
Collaborator Author

Code review

Reviewed and pushed follow-up fixes addressing handler composition, test patterns (rerender vs unmount), Theme/mockMatchMedia wrappers, and flaky focus assertions.

CI was green before fixes; please re-run checks on latest commit.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant